Skip to content

feat(tracing): Enable Sentry span streaming mode - #8245

Merged
phacops merged 7 commits into
masterfrom
sentry-span-streaming-snuba
Aug 3, 2026
Merged

feat(tracing): Enable Sentry span streaming mode#8245
phacops merged 7 commits into
masterfrom
sentry-span-streaming-snuba

Conversation

@phacops

@phacops phacops commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Why

Snuba runs the Sentry Python SDK in transaction mode: spans accumulate in memory and ship as one transaction event when the root span ends. Stream mode (trace_lifecycle="stream") sends them in batches as they finish, which gets us:

  • No 1000-span cap per transaction.
  • Flat memory in long-running consumers.
  • Spans survive crashes — a process killed mid-trace keeps whatever already flushed, instead of losing the whole transaction.

Why it's one big change

Stream mode disables the legacy tracing API, and it fails silently. start_span(), start_transaction() and update_current_span() become no-ops; get_current_span(), scope.span and scope.transaction return None. No exception, no warning at runtime. So every call site has to move to sentry_sdk.traces in the same commit or tracing just goes dark.

The translation: opsentry.op attribute, description → span name, set_data/set_tag on a span → set_attribute. StreamedSpan genuinely has no op, description, set_data, set_tag, or start_child, so mypy catches most of the sweep.

Tags vs. attributes

Worth a close look in review. Verified empirically against 2.66.1: scope tags and scope attributes reach disjoint destinations — tags land on error events only, attributes on spans only. Since stream mode emits no transaction event, query-path tags had to become attributes to stay queryable on trace data.

But the consumer and error-reporting paths (cli/consumer.py, lw_deletions_consumer.py, consumers/consumer.py, clickhouse/http.py) have no active span at all — no WSGI middleware, no root segment. Converting those would have sent the data nowhere, so they keep set_tag, with a comment explaining why.

Beyond the mechanical translation

  • Span-name cardinality. Several spans passed raw SQL as description, which becomes the span name — the grouping key — under stream mode. Unbounded cardinality. Now stable names with the SQL in db.query.text, matching what the SDK itself does.
  • Latent AttributeError. metrics.incr/metrics.timing don't exist in sentry-sdk 2.66.1, so SentryMetricsBackend would raise on any deploy with DOGSTATSD_SOCKET_PATH set. Mapped to count/distribution.
  • Three pre-existing mypy errors in ClickhouseConnectPool. Previously unreported because the mypy hook only checks changed files; touching that file surfaced them, so they're fixed here.
  • On-demand profiler deleted. It drives Transaction._profile, which has no stream-mode equivalent. Removed the module, its ondemand_profiler_hostnames option, and the docs section.

Verification

  • mypy . — clean across all 1079 files (was 3 errors on master).
  • 1986 tests pass with SDK deprecation warnings escalated to errors, which is the real check for missed call sites. Note -p no:warnings in pyproject.toml makes -W error::... inert, so this needed an addopts override — confirmed with a deliberate canary that the guard actually fires before trusting the green.
  • End-to-end against a capturing transport: a real Flask request emits an http.server segment with the migrated attributes; child spans nest correctly.
  • Querylog trace_id verified at sample rate 0 (the default) — NoOpStreamedSpan inherits the propagation context's id, so it stays a real 32-hex value. The RPC path now reads it from the propagation context directly, which would otherwise have silently started writing "".

Deploy note

SENTRY_TRACE_SAMPLE_RATE is 0 in every checked-in settings file, so this emits nothing until a deployment sets it (configured in ops, not here). One behavior change to be aware of: the CLI-init transaction used sampled=True to bypass sampling, and stream mode has no equivalent — it now obeys the sample rate like every other span. The snuba_init_time metric is unaffected.

🤖 Generated with Claude Code

Turn on `trace_lifecycle="stream"` so spans are sent to Sentry in batches as
they finish, instead of buffering a whole trace in memory and shipping it as a
single transaction event. This removes the 1000-span-per-transaction cap, keeps
memory flat in long-running consumers, and preserves spans that already finished
when a process is killed mid-trace (e.g. an OOM-kill).

Stream mode disables the legacy tracing API, and it fails silently:
`start_span()`, `start_transaction()` and `update_current_span()` become no-ops,
and `get_current_span()` / `scope.span` / `scope.transaction` return None. Every
call site therefore has to move to `sentry_sdk.traces` in the same change or
tracing goes dark with no error. `op` becomes the `sentry.op` attribute,
`description` becomes the span name, and `set_data`/`set_tag` on a span become
`set_attribute`.

Scope tags and scope attributes reach disjoint destinations: tags land on error
events only, attributes on spans only. Query-path tags are converted to
attributes so they stay queryable on trace data; the consumer and
error-reporting paths keep `set_tag`, since they have no active span and would
otherwise lose the data entirely.

Three things beyond the mechanical translation:

- Several spans passed raw SQL as `description`, which becomes the span name --
  the grouping key -- under stream mode. Give them stable names and move the SQL
  to a `db.query.text` attribute.
- `metrics.incr`/`metrics.timing` no longer exist in sentry-sdk 2.66.1, so
  SentryMetricsBackend was a latent AttributeError. Map them to `count` and
  `distribution`.
- Fix three pre-existing Mapping-vs-dict type errors in ClickhouseConnectPool.
  They were previously unreported because the mypy pre-commit hook only checks
  changed files, and this change touches that file.

Drop the on-demand profiler: it drives `Transaction._profile`, which has no
stream-mode equivalent.

Note that `SENTRY_TRACE_SAMPLE_RATE` is 0 in every checked-in settings file, so
this emits nothing until a deployment sets it. The CLI-init transaction
previously forced `sampled=True`; stream mode has no equivalent, so it now obeys
the sample rate like every other span.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@phacops
phacops requested review from a team as code owners July 30, 2026 22:08
Comment thread snuba/web/views.py Outdated
Comment thread snuba/web/rpc/storage_routing/routing_strategies/storage_routing.py Outdated
Comment thread snuba/clickhouse/connect.py Outdated
Comment thread snuba/clickhouse/connect.py Outdated
Comment thread snuba/clickhouse/connect.py
Comment thread snuba/datasets/plans/entity_validation.py Outdated
Comment thread snuba/utils/metrics/util.py Outdated
Comment thread .pre-commit-config.yaml Outdated

@alexander-alderman-webb alexander-alderman-webb left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me overall.
The two bot comments look valid.

The main possibly disruptive change is calling Scope.set_attribute() where there was previously Scope.set_tag(), which includes edits of sentry_sdk.set_tag() -> sentry_sdk.set_attribute().

Keep in mind that attributes on the scope are applied to logs, metrics, and (streamed) spans, whereas tags are applied to exceptions. Completely replacing set_tag() with set_attribute() results in the data not showing up on exceptions while the scope is active.

Comment thread snuba/utils/metrics/backends/sentry.py Outdated
phacops added 2 commits July 31, 2026 15:37
Dual-write scope tags and attributes on API error-enrichment paths so
values still land on Sentry issues during the tags→attributes transition.
Centralize the stream-mode op key as SENTRY_OP, harden querylog trace_id
lookup, clarify clickhouse-connect param narrowing, and always set insert
query_id on spans.
client.insert takes a row matrix rather than SQL, but it still builds and
sends "INSERT INTO <table> (<cols>) FORMAT Native" on the wire, so the
statement was available all along. Reconstruct it for db.query.text instead
of leaving the attribute unset.

This also brings the two pools in line: ClickhousePool.insert routes through
execute() with an explicit statement and already populated the attribute, so
only the HTTP path was missing it.

Row data stays out of the attribute since it is unbounded and may hold PII.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 2087ec8. Configure here.

Comment thread snuba/web/db_query.py Outdated
Comment thread snuba/web/views.py Outdated
phacops added 3 commits August 2, 2026 12:09
Bugbot caught five scope-level set_tag call sites the sweep converted to
set_attribute only: slo_status, cache_status, query_size_group, and the
querylog duration_group/timeout/experiments/max_threads block.

Scope tags and attributes reach disjoint destinations, so attribute-only
means these stop appearing on Sentry issues. The API error-enrichment paths
already dual-write via set_tag_and_attribute; these were missed. Route them
through the same helper so no bare sentry_sdk.set_attribute is left outside
it. Verified against a capturing transport that the value lands on both the
error event tags and the streamed span attributes.

Also normalize the referrer once in _trace_transaction so the transaction
name matches the referrer attribute instead of interpolating a literal
"None" when the Referer header is absent.
The SDK annotates metrics attributes as an invariant dict while Tags is a
Mapping, so the call needed narrowing. _attributes did that with dict(tags),
which is a redundant copy: _capture_metric already iterates .items() into a
fresh dict and never retains or mutates the caller's mapping.

Cast instead. Verified the SDK accepts a non-dict Mapping at runtime and that
all four metric types still emit with correct type, unit, and attributes.

Narrowing the override signatures instead would violate LSP against
MetricsBackend, and callers reach this through the abstract interface, so the
conversion has to stay at the call boundary.
Comment thread snuba/utils/metrics/util.py Outdated
Comment thread snuba/utils/metrics/util.py
@phacops
phacops merged commit 3df3b41 into master Aug 3, 2026
69 checks passed
@phacops
phacops deleted the sentry-span-streaming-snuba branch August 3, 2026 15:38
@linear-code

linear-code Bot commented Aug 3, 2026

Copy link
Copy Markdown

EAP-632

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants